Infrastructure as Code

David Watson
Cloud Solution Architect

Title

Agenda

Introduction to Bicep

  • What is Bicep?
  • Getting Started
  • Template Fundamentals
  • Deploying Bicep
  • Advanced Template Topics

Deploying with ADO Pipelines

  • Why Pipeline-based IaC?
  • Service Connections
  • Deployment Tasks
  • Multi-Environment Pipelines
  • Best Practices

What is Bicep?

What is Bicep?

  • A domain-specific language (DSL) for deploying Azure resources
  • Transparent abstraction over ARM templates (JSON)
  • Compiles to ARM JSON — anything ARM can do, Bicep can do
  • First-class support from Microsoft — ships with Azure CLI
  • Declarative syntax — you describe what, Azure handles how

Bicep vs ARM Templates

Bicep compiles to ARM JSON — same resource, dramatically less code

ARM (JSON)


{
  "type": "Microsoft.Storage/
    storageAccounts",
  "apiVersion": "2023-01-01",
  "name": "[parameters('name')]",
  "location": "[resourceGroup()
    .location]",
  "sku": {
    "name": "[parameters('sku')]"
  },
  "kind": "StorageV2"
}
              

Bicep


resource sa 'Microsoft.Storage/
  storageAccounts@2023-01-01' = {
  name: storageAccountName
  location: resourceGroup().location
  sku: {
    name: skuName
  }
  kind: 'StorageV2'
}

              

Why Bicep over ARM?

Simpler Syntax
No JSON noise — cleaner, more readable templates
Type Safety
Compile-time validation catches errors before deployment
Modules
First-class support for reusable, composable templates
Tooling
VS Code extension with IntelliSense, snippets, and linting

Getting Started

Tools & Setup

  • VS Code + Bicep extension — IntelliSense, validation, snippets
  • Azure CLI (v2.20+) — Bicep is built-in, no separate install
  • Azure PowerShell (Az module 5.6+) — also supports Bicep natively
  • Bicep CLIaz bicep install / az bicep upgrade

# Verify Bicep is installed
az bicep version

# Upgrade to latest
az bicep upgrade

# Build (compile to ARM JSON) — useful for inspection
az bicep build --file main.bicep
          

Your First Bicep File

A Bicep file has three sections: parameters (inputs), resources (what to deploy), and outputs (return values)


// Parameters
param location string = resourceGroup().location
param storageAccountName string

// Resource
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
}

// Output
output storageId string = storageAccount.id
          

Template Fundamentals

Parameters

Inputs to your template


// Basic parameter with type
param environment string

// With default value
param location string = resourceGroup().location

// With allowed values
@allowed(['dev', 'staging', 'prod'])
param environmentName string

// With constraints
@minLength(3)
@maxLength(24)
@description('Globally unique storage account name')
param storageAccountName string
          

Parameter Types

Bicep supports primitive types, complex types, and secure types for secrets


// Primitive types
param name string
param count int
param isEnabled bool

// Complex types
param tags object = {
  environment: 'dev'
  team: 'platform'
}

param allowedIps array = [
  '10.0.0.0/24'
  '172.16.0.0/16'
]

// Secure types — never logged or stored in plain text
@secure()
param adminPassword string
          

Variables

Unlike parameters (caller-provided inputs), variables are computed inside the template and used to simplify repeated expressions


param environment string
param appName string
param location string = resourceGroup().location

// Simple variable
var resourcePrefix = '${appName}-${environment}'

// Computed variable
var storageName = toLower(replace('${resourcePrefix}sa', '-', ''))

// Complex variable
var commonTags = {
  environment: environment
  application: appName
  managedBy: 'Bicep'
}
          

Resources

Each resource has a symbolic name (for Bicep references), a type with API version, and its properties


// Symbolic name    Resource type + API version
resource appPlan 'Microsoft.Web/serverfarms@2023-01-01' = {
  name: '${resourcePrefix}-plan'
  location: location
  sku: {
    name: 'B1'
    tier: 'Basic'
  }
  kind: 'linux'
  properties: {
    reserved: true    // Required for Linux
  }
}
          

Resource Dependencies

Reference one resource from another and Bicep automatically handles deployment order — no manual dependsOn needed


resource appPlan 'Microsoft.Web/serverfarms@2023-01-01' = {
  name: '${resourcePrefix}-plan'
  location: location
  sku: { name: 'B1' }
}

// Referencing appPlan.id creates an implicit dependency
resource webApp 'Microsoft.Web/sites@2023-01-01' = {
  name: '${resourcePrefix}-app'
  location: location
  properties: {
    serverFarmId: appPlan.id    // ← implicit dependency
  }
}
          

Outputs

Outputs expose values from a deployment — essential for chaining modules and for use in pipeline scripts


// Output a resource property
output appUrl string = 'https://${webApp.properties.defaultHostName}'

// Output a resource ID
output appServiceId string = webApp.id

// Output a complex object
output connectionInfo object = {
  serverName: sqlServer.properties.fullyQualifiedDomainName
  databaseName: sqlDatabase.name
}
          

String Interpolation & Functions

Bicep provides ${} interpolation, 60+ built-in functions, and a ternary operator for inline conditionals


// String interpolation
var greeting = 'Hello, ${name}!'
var resourceName = '${prefix}-${environment}-${suffix}'

// Built-in functions
var lowerName = toLower(storageAccountName)
var uniqueName = '${prefix}${uniqueString(resourceGroup().id)}'
var subnetId = resourceId('Microsoft.Network/virtualNetworks/subnets',
                          vnetName, subnetName)

// Ternary operator
var skuName = isProd ? 'Standard_GRS' : 'Standard_LRS'
var tier = contains(premiumRegions, location) ? 'Premium' : 'Standard'
          

Conditional Deployments

Use if to deploy resources only when a condition is true — ideal for environment-specific resources


param deployDiagnostics bool = true
param environment string

// Deploy resource conditionally
resource diagStorage 'Microsoft.Storage/storageAccounts@2023-01-01' =
  if (deployDiagnostics) {
  name: '${resourcePrefix}diag'
  location: location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
}

// Conditional based on parameter value
resource appInsights 'Microsoft.Insights/components@2020-02-02' =
  if (environment == 'prod') {
  name: '${resourcePrefix}-ai'
  location: location
  kind: 'web'
  properties: { Application_Type: 'web' }
}
          

Loops

Deploy multiple instances with for


// Loop over an array
param locations array = ['australiaeast', 'southeastasia']

resource storageAccounts 'Microsoft.Storage/storageAccounts@2023-01-01' = [
  for loc in locations: {
    name: '${resourcePrefix}${uniqueString(loc)}'
    location: loc
    sku: { name: 'Standard_LRS' }
    kind: 'StorageV2'
  }
]

// Loop with index
resource subnets 'Microsoft.Network/virtualNetworks/subnets@2023-05-01' = [
  for (name, i) in subnetNames: {
    name: name
    properties: {
      addressPrefix: '10.0.${i}.0/24'
    }
  }
]
          

Modules

Modules

A module is just a Bicep file called from another — pass in parameters, get back outputs, like a function

Module file


// modules/storage.bicep
param name string
param location string
param sku string = 'Standard_LRS'

resource sa 'Microsoft.Storage/
  storageAccounts@2023-01-01' = {
  name: name
  location: location
  sku: { name: sku }
  kind: 'StorageV2'
}

output id string = sa.id
output endpoint string =
  sa.properties
    .primaryEndpoints.blob
              

Consuming module


// main.bicep
param environment string
param location string =
  resourceGroup().location

module storage 'modules/storage.bicep'
  = {
  name: 'storageDeployment'
  params: {
    name: '${environment}store01'
    location: location
    sku: 'Standard_GRS'
  }
}

// Use module output
output blobEndpoint string =
  storage.outputs.endpoint
              

Module Scoping

Modules can target different resource groups or subscriptions using the scope property


// Deploy to a different resource group
module networkModule 'modules/network.bicep' = {
  name: 'networkDeploy'
  scope: resourceGroup('networking-rg')
  params: {
    vnetName: 'main-vnet'
    location: location
  }
}

// Deploy to a subscription scope (e.g., create resource groups)
targetScope = 'subscription'

resource rg 'Microsoft.Resources/resourceGroups@2023-07-01' = {
  name: 'myapp-${environment}-rg'
  location: location
}

module appModule 'modules/app.bicep' = {
  name: 'appDeploy'
  scope: rg    // Deploy into the resource group we just created
  params: { ... }
}
          

Deploying Bicep

Deploying with Azure CLI

The CLI compiles Bicep to ARM JSON automatically — pass parameters inline or via a file


# Deploy to a resource group
az deployment group create \
  --resource-group myapp-rg \
  --template-file main.bicep \
  --parameters environment='dev' location='australiaeast'

# Deploy with a parameter file
az deployment group create \
  --resource-group myapp-rg \
  --template-file main.bicep \
  --parameters @parameters.dev.json

# Subscription-scope deployment
az deployment sub create \
  --location australiaeast \
  --template-file main.bicep \
  --parameters @parameters.json
          

Parameter Files

Keep one parameter file per environment — same template, different configuration values

Bicep parameter file (.bicepparam)


using './main.bicep'

param environment = 'dev'
param location = 'australiaeast'
param storageAccountName = 'mydevstore01'
param tags = {
  environment: 'dev'
  costCenter: 'IT'
}
              

JSON parameter file


{
  "$schema": "https://schema.management
    .azure.com/schemas/2019-04-01
    /deploymentParameters.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "environment": {
      "value": "dev"
    },
    "location": {
      "value": "australiaeast"
    }
  }
}
              

What-If & Validation

Preview changes before deploying


# Validate template without deploying
az deployment group validate \
  --resource-group myapp-rg \
  --template-file main.bicep \
  --parameters @parameters.dev.json

# Preview changes (What-If)
az deployment group what-if \
  --resource-group myapp-rg \
  --template-file main.bicep \
  --parameters @parameters.dev.json
          

Deployment Scopes

Bicep can deploy at four levels: resource group, subscription, management group, or tenant


# Resource group scope (most common)
az deployment group create -g myapp-rg -f main.bicep

# Subscription scope — for resource groups, policies, RBAC
az deployment sub create -l australiaeast -f main.bicep

# Management group scope — for policies across subscriptions
az deployment mg create -m myManagementGroup -l australiaeast \
  -f main.bicep

# Tenant scope — for management groups, subscriptions
az deployment tenant create -l australiaeast -f main.bicep
          

Advanced Template Topics

Existing Resources

Reference resources that already exist


// Reference an existing resource in the same resource group
resource existingVnet 'Microsoft.Network/virtualNetworks@2023-05-01'
  existing = {
  name: 'main-vnet'
}

// Reference a resource in a different resource group
resource existingKv 'Microsoft.KeyVault/vaults@2023-07-01'
  existing = {
  name: 'shared-keyvault'
  scope: resourceGroup('shared-services-rg')
}

// Use the existing resource's properties
output subnetId string = existingVnet.properties.subnets[0].id
output kvUri string = existingKv.properties.vaultUri
          

Child Resources

Resources like SQL databases or blob containers are children of a parent — Bicep offers two declaration styles


// Option 1: Nested declaration
resource sqlServer 'Microsoft.Sql/servers@2023-05-01-preview' = {
  name: sqlServerName
  location: location
  properties: { ... }

  // Child resource — nested inside parent
  resource database 'databases' = {
    name: 'mydb'
    location: location
    sku: { name: 'Basic', tier: 'Basic' }
  }
}

// Option 2: Top-level with parent property
resource database 'Microsoft.Sql/servers/databases@2023-05-01-preview' = {
  parent: sqlServer
  name: 'mydb'
  location: location
  sku: { name: 'Basic', tier: 'Basic' }
}
          

User-Defined Types

Strongly typed parameter objects


// Define a custom type
type appConfig = {
  name: string
  @allowed(['B1', 'S1', 'P1v3'])
  skuName: string
  instanceCount: int
  enableDiagnostics: bool
}

// Use the type as a parameter
param config appConfig

// Use in resources
resource appPlan 'Microsoft.Web/serverfarms@2023-01-01' = {
  name: config.name
  sku: { name: config.skuName, capacity: config.instanceCount }
}
          

Bicep Registry & Template Specs

Share versioned modules across teams — publish to a registry and consume by reference

Bicep Registry (ACR)


# Publish to registry
az bicep publish \
  --file modules/storage.bicep \
  --target br:myregistry.azurecr.io/
    bicep/storage:v1.0
              

// Consume from registry
module storage
  'br:myregistry.azurecr.io/
    bicep/storage:v1.0' = {
  name: 'storageDeploy'
  params: { ... }
}
              

Template Specs


# Create a template spec
az ts create \
  --name storageSpec \
  --resource-group templates-rg \
  --version 1.0 \
  --template-file main.bicep
              

// Consume as module
module storage
  'ts:sub-id/templates-rg
    /storageSpec:1.0' = {
  name: 'storageDeploy'
  params: { ... }
}
              

Azure Verified Modules

Pre-built, Microsoft-maintained Bicep modules — tested, compliant, and ready to use

What you get

  • Modules for 300+ Azure resource types
  • Follows Well-Architected Framework best practices
  • Published to the Bicep public module registry
  • Versioned, tested & maintained by Microsoft

Consume in one line


module storage 'br/public:avm/res/
  storage/storage-account:0.9.0' = {
  name: 'storageDeploy'
  params: {
    name: storageAccountName
    location: location
    skuName: 'Standard_GRS'
  }
}
              

azure.github.io/Azure-Verified-Modules

Azure Landing Zone Accelerator

A complete, opinionated Bicep baseline for enterprise Azure environments

What it provides

  • Management group hierarchy & subscriptions
  • Azure Policy assignments for governance
  • Hub-and-spoke networking with connectivity
  • Identity, logging & security baselines
  • Modular — adopt the pieces you need

Who it's for

  • Platform teams setting up a new Azure tenant
  • Organisations needing governance at scale
  • Teams migrating from portal-managed environments
  • Anyone following the Cloud Adoption Framework

github.com/Azure/ALZ-Bicep

Bicep Documentation

The official Bicep docs are your go-to reference for syntax, resource types, and best practices

learn.microsoft.com/…/bicep

Comprehensive reference

Every Azure resource type documented with full Bicep syntax and property descriptions

Quickstart templates

Ready-to-use Bicep samples for common scenarios — great starting points for your own templates

Learn modules

Step-by-step Microsoft Learn paths covering Bicep fundamentals through advanced patterns

Bicep documentation screenshot

Deploying with ADO Pipelines

Why Pipeline-based IaC?

  • Version control — Bicep files in the same repo as app code
  • Peer review — PR-based review before infrastructure changes
  • Consistency — same process for every environment
  • Audit trail — every change tracked with commit + pipeline run
  • Automated validation — lint, validate, and what-if before deploy

Service Connections

Pipelines authenticate to Azure via service connections — use workload identity federation (no secrets to rotate)


# Pipeline references the service connection by name
steps:
  - task: AzureCLI@2
    inputs:
      azureSubscription: 'my-azure-connection'  # ← Service connection
      scriptType: 'bash'
      scriptLocation: inlineScript
      inlineScript: |
        az deployment group create \
          --resource-group myapp-rg \
          --template-file infra/main.bicep \
          --parameters @infra/parameters.dev.json
          

The AzureResourceManagerTemplateDeployment Task

A dedicated ADO task that compiles Bicep and submits the deployment — use Incremental mode (the safe default)


- task: AzureResourceManagerTemplateDeployment@3
  displayName: 'Deploy Bicep template'
  inputs:
    azureResourceManagerConnection: 'my-azure-connection'
    action: 'Create Or Update Resource Group'
    resourceGroupName: 'myapp-dev-rg'
    location: 'australiaeast'
    templateLocation: 'Linked artifact'
    csmFile: 'infra/main.bicep'
    csmParametersFile: 'infra/parameters.dev.json'
    deploymentMode: 'Incremental'
          

Validate & What-If in Pipelines

Gate deployments with pre-checks


stages:
  - stage: Validate
    jobs:
      - job: ValidateTemplate
        steps:
          - task: AzureCLI@2
            displayName: 'Bicep lint & build'
            inputs:
              azureSubscription: 'my-azure-connection'
              scriptType: 'bash'
              scriptLocation: inlineScript
              inlineScript: |
                az bicep build --file infra/main.bicep
                az bicep lint --file infra/main.bicep

          - task: AzureCLI@2
            displayName: 'What-If preview'
            inputs:
              azureSubscription: 'my-azure-connection'
              scriptType: 'bash'
              scriptLocation: inlineScript
              inlineScript: |
                az deployment group what-if \
                  --resource-group myapp-dev-rg \
                  --template-file infra/main.bicep \
                  --parameters @infra/parameters.dev.json
          

Environment-based Deployments

Same Bicep template for every environment — only the service connection, resource group, and parameter file change


stages:
  - stage: DeployDev
    jobs:
      - deployment: DeployInfra
        environment: 'Dev'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureResourceManagerTemplateDeployment@3
                  inputs:
                    azureResourceManagerConnection: 'azure-dev'
                    resourceGroupName: 'myapp-dev-rg'
                    csmFile: 'infra/main.bicep'
                    csmParametersFile: 'infra/parameters.dev.json'

  - stage: DeployProd
    dependsOn: DeployDev
    jobs:
      - deployment: DeployInfra
        environment: 'Production'    # ← Approval gate here
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureResourceManagerTemplateDeployment@3
                  inputs:
                    azureResourceManagerConnection: 'azure-prod'
                    resourceGroupName: 'myapp-prod-rg'
                    csmFile: 'infra/main.bicep'
                    csmParametersFile: 'infra/parameters.prod.json'
          

Complete IaC Pipeline

Validate → Deploy Dev (auto) → Deploy Prod (approval) — triggered only when infra/ files change


trigger:
  branches:
    include: [main]
  paths:
    include: [infra/**]

variables:
  templateFile: 'infra/main.bicep'

stages:
  - stage: Validate
    jobs:
      - job: Lint
        steps:
          - task: AzureCLI@2
            inputs:
              azureSubscription: 'azure-dev'
              scriptType: bash
              scriptLocation: inlineScript
              inlineScript: az bicep build --file $(templateFile)

  - stage: DeployDev
    dependsOn: Validate
    jobs:
      - deployment: Deploy
        environment: Dev
        strategy:
          runOnce:
            deploy:
              steps:
                - checkout: self
                - task: AzureResourceManagerTemplateDeployment@3
                  inputs:
                    azureResourceManagerConnection: 'azure-dev'
                    resourceGroupName: 'myapp-dev-rg'
                    location: 'australiaeast'
                    csmFile: '$(templateFile)'
                    csmParametersFile: 'infra/parameters.dev.json'

  - stage: DeployProd
    dependsOn: DeployDev
    condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
    jobs:
      - deployment: Deploy
        environment: Production
        strategy:
          runOnce:
            deploy:
              steps:
                - checkout: self
                - task: AzureResourceManagerTemplateDeployment@3
                  inputs:
                    azureResourceManagerConnection: 'azure-prod'
                    resourceGroupName: 'myapp-prod-rg'
                    location: 'australiaeast'
                    csmFile: '$(templateFile)'
                    csmParametersFile: 'infra/parameters.prod.json'
          

IaC Pipeline Best Practices

Path Triggers
Only run IaC pipeline when infra/ files change
Validate First
Lint, build, and what-if before any deployment
Per-Env Params
One parameter file per environment, same template
Approval Gates
Require human approval for production deployments

Key Takeaways

  • Bicep is the recommended IaC language for Azure
  • Modules make templates reusable and composable
  • What-If previews changes before they happen
  • ADO Pipelines automate and gate your deployments
  • Same template, different params across environments

Hands-On Lab

Author & deploy Bicep templates with Azure CLI and ADO Pipelines

Objectives

  • Author Bicep files with parameters, variables & modules
  • Deploy resources to Azure using the Azure CLI
  • Use parameter files and secure parameters
  • Apply conditions and loops in Bicep
  • Deploy Bicep templates via an ADO pipeline

Prerequisites

  • Access to an Azure subscription & resource group
  • Azure CLI installed and configured
  • Code editor (VS Code recommended with Bicep extension)

Questions?

Time for hands-on labs